DNS mit Docker Teil III – dnsdist als Router / Loadbalancer

Wer meine bisherigen Posts zu Docker und DNS verfolgt hat, weiß: Da steht noch was aus (ich komme nur momentan nicht immer ganz zeitnah dazu meine Erfahrungen zusammen zu schreiben.

Was bisher geschah:

Was jetzt noch aussteht ist eine Sache die mit der Microservice-Architektur von PowerDNS im Vergleich zu „klassischen“ Nameservern wie BIND zusammen hängt: BIND ist monolitisch und kümmert sich um jegliche Anfrage die gestellt wird. In PowerDNS hat man diese Dinge wie oben bereits angedeutet aufgetrennt: Einen Spezialisten zum Auflösen von externen Einträgen (Recursor) und einen weiteren Prozess der sich um die Sachen kümmert für die man selbst verantwortlich ist (authorative).

Die einfach Lösung wäre: man muss wissen ob man jetzt den authorative Nameserver ansprechen will oder den Recursor – aber das klappt ja nur wenn man weiß wer für welche Domains zuständig ist. Außerdem möchte man ja vermeiden, dass der eigene Nameserver ggf. in dDOS-Attacken mit eingebunden wird. Man kann natürlich auf den Recursor verzichten und nur einen authorative Server laufen lassen, Anfragen der Clients sollen doch bitte an einen anderen DNS-Server gehen der das aussortiert… Könnte man machen, ist aber nicht sinnvoll, insbesondere wenn man viele Abfragen stellt (z.B. weil der Mailserver Blacklists via DNS abfragt und man da gerne in Beschränkungen läuft) – da hilft ein lokaler Cache sehr zuverlässig. Natürlich ist es auch möglich der einen Recursor zu betreiben wenn man keine eigene Domain sein eigen nennt aber die Abfragen dennoch cachen möchte. Das hängt dann ganz davon ab was man konkret benötigt.

Die empfohlene saubere Lösung ist, man ahnt es schon: ein weiterer Microservice der nichts anderes macht als zu entscheiden was mit einer Anfrage geschehen soll (vergleichbar mit einem Empfang in einem Gebäude an dem jeder vorbei muss und gesagt bekommt wo er hin muss bzw. das er nicht weiter kommt). Dieses Stückchen Software heißt bei PowerDNS „dnsdist“.

Im ersten Anlauf habe ich das nicht als Docker-Container realisiert sondern direkt nativ auf der ausführenden Maschine. Das hat nicht nur Vorteile und im nächsten Schritt werde ich auch diesen Service in einen Docker-Container verschieben. Es macht jedoch die Konfiguration und das Experimentieren etwas leichter da der Server direkt von außen auf der nativen IP ansprechbar ist die dem externen Interface zugewiesen ist. Das ist gerade im Hinblick auf IPv4 vorteilhaft, denn der Zielprozess kann die Anfragen direkt entgegen nehmen, es muss nicht erst ein „Umpacken“ auf das interne DNS-Netzwerk des Docker-Systems erfolgen. Ich habe es sogar so konfiguriert, dass dnsdist per IPv4 angenommene Pakete die es weiterleiten soll per IPv6 an die passenden Hosts in Dockernetz weiter schickt.

Die Konfiguration von dnsdist ist recht einfach und auch gut dokumentiert

Hier legen wir fest auf welche Interfaces dnsdist lauschen soll, in diesem Fall alles was lokal verfügbar ist:

addLocal("0.0.0.0:53")
addLocal("[::]:53")
setACL({"0.0.0.0/0","::/0"})

Die nachgelagerten DNS-Server muss man dnsdist natürlich auch noch mitteilen – wichtig ist dabei: Man kann mehrere Server in Pools zusammenfassen, dnsdist sorgt dann für eine gleichmäßige Belastung. Ich habe nur zwei Pools definiert, einen in dem ich zwei authorative Server hinterlegt habe und einen in dem der Recursor läuft. Das mit den zwei authoratives ist nicht zwingend notwendig, macht es aber einfacher ggf. einen gegen eine neuer Fassung auszutauschen (ggf. auch nur mit einer anderen Konfiguration)

newServer({address="[2001:db8:ffff:ffff::2]:53", pool="authorative"})
newServer({address="[2001:db8:ffff:ffff::4]:53", pool="authorative"})
newServer({address="[2001:db8:ffff:ffff::3]:53", pool="recursor"})

Dnsdist bringt auch noch einige Dinge zur Verwaltung und zu Monitoring mit, unter anderem einen integrierten Webserver (den man tunlichst mit einem Password absichern sollte), zudem gibt es für den Webserver auch einen API-Key mit dem man ggf. zur Laufzeit Einfluss auf die Konfiguration nehmen kann. Die maintenance-Funktion dient als Schutz gegen Denial-of-Service Angriffe oder auch den Missbrauch des Servers als Relay/Amplifier für dDOS-Attacken. Wer zu häufig fragt kommt auf die dynamische Blockliste.

webserver("[::1]:8103", "superpassword", "api-key")
-- Block hosts that exceeded 100 queries in 10 seconds for 30 minutes 
function maintenance() 
    addDynBlocks(exceedQRate(100, 10), "Exceeded query rate", 1800) 
end

Da wir für den Betrieb einer Second-Level-Domain gemäß DENIC immer mindestens zwei DNS-Server (besser sogar 3-4) benennen müssen die in fremden Netzwerken stehen sollten müssen wir für diese Server explizit erlauben, dass diese sogenannte ZonenTransfers durchführen können. Der Request dazu heißt im DNS „AXFR“ für eine vollständige Übertragung und „IXFR“ für eine inkrementelle Übertragung (etwas moderner wird aber nicht von allen DNS-Servern unterstützt). Für meinen Fall habe ich dort die Sekundären Server bei meinem Hoster angegeben, welche sich dann entsprechend mit den Daten versorgen und als Fallback / Loadbalancing dienen falls der eigene Server mal nicht erreichbar sein sollte.

addAction( 
   AndRule( 
       { OrRule( 
          {QTypeRule(dnsdist.AXFR), QTypeRule(dnsdist.IXFR)}
         ),
         NotRule( 
           makeRule( 
             {"192.168.150.53/32", -- own host ipv4 
              "2001:db8:ffff:ffff::/64", -- own server ip(s) 
              -- external / secondary ipv4 
              "10.49.157.17/32", -- externer nameserver ipv4 
              "2001:db8:ffff:fffe::53/128", -- externer nameserver ipv6 
             }
          )
        )
      }
 ),
 RCodeAction(dnsdist.REFUSED)
)

Zu guter Letzt muss man noch angeben welche Anfragen wohin weitergereicht werden sollen. Hat man Subdomains im Einsatz, so reicht es die übergeordnete Domain einzutragen. Will man diese in einen getrennten Pool auslagern, so ist die Reihenfolge wichtig: von lang nach kurz, der erste Treffer greift. Hier landen beispielhaft alle Anfragen für zwei von uns verwaltete Domains bei den authorative Nameservern die wir aufgesetzt und dem Pool zugeordnet haben. Alle anderen Anfragen werden daraufhin untersucht ob sie von einer lokalen Adresse bzw. aus einem lokalen Subnetz stammen, diese werden an den Recursor weiter geleitet. Alle anderen Anfragen lassen wir einfach unbeantwortet, das ist eine Sicherungsmaßnahme gegen Missbrauch, da man sonst den Server für dDoS-Attacken missbrauchen könnte. Setzt man den Server in einem lokalen Netz als Caching-Instanz ein, so darf man diese Anfragen natürlich nicht verwerfen.

addAction("example.com.", PoolAction("authorative"))
addAction("myexample.com", PoolAction("authorative"))

-- ... other domains use recursor if queried locally 
nmg = newNMG();
nmg:addMask('2001:db8:ffff:ffff::/64') 
nmg:addMask('::1/128') 
nmg:addMask('127.0.0.0/8') 
nmg:addMask('192.168.150.53/32') 

addAction(NetmaskGroupRule(nmg),PoolAction("recursor")) 

addAction(AllRule(), DropAction()) 

Somit ist dnsdist fertig konfiguriert und kann gestartet werden. Für diejenigen die es interessiert, die Konfiguration ist in LUA geschrieben, wen notwendig kann man sich damit auch eine beliebig komplexe Regelung ausprogrammieren.

Der letze Schritt ist dann eigentlich nur noch Formsache, denn dnsdist ist vergleichsweise pflegleicht und man kann es sehr leicht in einen Docker-Container verpacken:

FROM tcely/dnsdist

COPY dnsdist.conf /etc/dnsdist/dnsdist.conf
EXPOSE 	53/TCP 53/UDP

ENTRYPOINT ["/usr/local/bin/dnsdist", "--uid", "dnsdist", "--gid", "dnsdist"]
CMD ["--disable-syslog"]

Wobei man die Konfigurationsdatei aus dem bestehenden System kopieren kann. Wie man dem Dockerfile erkennen kann mus man hier zu einer Kompatibilität für IPv4 greifen und mit EXPOSE arbeiten. Das ist insofern unschön, da es automatisiert in die Firewall des Host-Systems eingreift. Alternativ kann man natürlich den DNSDist auf dem Host als Loadbalancer weiter laufen lassen. Damit ist dann aber die Kapselung des Gesamtsystems DNS dahin.

Ich habe mich für eine Hybrid-Lösung entschieden und mounte kurzerhand das Konfigurationsfile aus dem Hostsystem in den Container:

docker run -d -v /etc/dnsdist/:/etc/dnsdist/ --ip6 2001:db8:ffff:ffff::10 --network dns --name pdns_dnsdist powerdns-dnsdist

Im DNS trägt man dann für den IPv6 Bereich natürlich den Docker-Container ein. Für IPv4 belässt man es beim Hostsystem. Insgesamt ist das derzeit eine Brücke die man wohl auch noch eine ganze Weile beibehalten muss, bis IPv6 endlich durchgängig im Einsatz ist.

Ich befürchte nur leider, dass es noch eine ganze Weile dauern wird. Ich hoffe mit dem hier vorgestellten Setup den Einen oder anderen dazu anregen sich einmal mehr mit IPv6 und DNS auseinander zu setzen und die Vorteile gerade im Umfeld von Docker oder anderen Virtualisierungen zu nutzen.